Italiano

Esplora pattern avanzati per la React Context API, inclusi componenti composti, contesti dinamici e tecniche di ottimizzazione delle prestazioni per la gestione di stati complessi.

Pattern Avanzati della React Context API per la Gestione dello Stato

La React Context API fornisce un potente meccanismo per condividere lo stato attraverso la tua applicazione senza il "prop drilling". Sebbene l'uso di base sia semplice, sfruttarne appieno il potenziale richiede la comprensione di pattern avanzati in grado di gestire scenari complessi di gestione dello stato. Questo articolo esplora diversi di questi pattern, offrendo esempi pratici e spunti concreti per elevare il tuo sviluppo con React.

Comprendere i Limiti della Context API di Base

Prima di immergersi nei pattern avanzati, è fondamentale riconoscere i limiti della Context API di base. Sebbene sia adatta per stati semplici e accessibili a livello globale, può diventare ingombrante e inefficiente per applicazioni complesse con stati che cambiano frequentemente. Ogni componente che consuma un contesto si ri-renderizza ogni volta che il valore del contesto cambia, anche se il componente non dipende dalla parte specifica dello stato che è stata aggiornata. Questo può portare a colli di bottiglia nelle prestazioni.

Pattern 1: Componenti Composti con il Contesto

Il pattern dei Componenti Composti (Compound Component) migliora la Context API creando una suite di componenti correlati che condividono implicitamente stato e logica attraverso un contesto. Questo pattern promuove la riutilizzabilità e semplifica l'API per i consumatori. Ciò consente di incapsulare logiche complesse con un'implementazione semplice.

Esempio: un Componente Tab

Illustriamo questo concetto con un componente Tab. Invece di passare le props attraverso più livelli, i componenti Tab comunicano implicitamente attraverso un contesto condiviso.

// TabContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface TabContextType {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabContext = createContext(undefined);

interface TabProviderProps {
  children: ReactNode;
  defaultTab: string;
}

export const TabProvider: React.FC = ({ children, defaultTab }) => {
  const [activeTab, setActiveTab] = useState(defaultTab);

  const value: TabContextType = {
    activeTab,
    setActiveTab,
  };

  return {children};
};

export const useTabContext = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error('useTabContext must be used within a TabProvider');
  }
  return context;
};

// TabList.js
import React, { ReactNode } from 'react';

interface TabListProps {
  children: ReactNode;
}

export const TabList: React.FC = ({ children }) => {
  return 
{children}
; }; // Tab.js import React, { ReactNode } from 'react'; import { useTabContext } from './TabContext'; interface TabProps { label: string; children: ReactNode; } export const Tab: React.FC = ({ label, children }) => { const { activeTab, setActiveTab } = useTabContext(); const isActive = activeTab === label; const handleClick = () => { setActiveTab(label); }; return ( ); }; // TabPanel.js import React, { ReactNode } from 'react'; import { useTabContext } from './TabContext'; interface TabPanelProps { label: string; children: ReactNode; } export const TabPanel: React.FC = ({ label, children }) => { const { activeTab } = useTabContext(); const isActive = activeTab === label; return ( ); };
// Utilizzo
import { TabProvider, TabList, Tab, TabPanel } from './components/Tabs';

function App() {
  return (
    
      
        Tab 1
        Tab 2
        Tab 3
      
      Contenuto per Tab 1
      Contenuto per Tab 2
      Contenuto per Tab 3
    
  );
}

export default App;

Vantaggi:

Pattern 2: Contesti Dinamici

In alcuni scenari, potresti aver bisogno di valori di contesto diversi in base alla posizione del componente nell'albero dei componenti o ad altri fattori dinamici. I contesti dinamici ti consentono di creare e fornire valori di contesto che variano in base a condizioni specifiche.

Esempio: Theming con Contesti Dinamici

Considera un sistema di temi in cui desideri fornire temi diversi in base alle preferenze dell'utente o alla sezione dell'applicazione in cui si trovano. Possiamo fare un esempio semplificato con un tema chiaro e uno scuro.

// ThemeContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const defaultTheme: Theme = {
    background: 'white',
    color: 'black'
};

const darkTheme: Theme = {
    background: 'black',
    color: 'white'
};

const ThemeContext = createContext({
    theme: defaultTheme,
    toggleTheme: () => {}
});

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC = ({ children }) => {
  const [isDarkTheme, setIsDarkTheme] = useState(false);
  const theme = isDarkTheme ? darkTheme : defaultTheme;

  const toggleTheme = () => {
    setIsDarkTheme(!isDarkTheme);
  };

  const value: ThemeContextType = {
    theme,
    toggleTheme,
  };

  return {children};
};

export const useTheme = () => {
  return useContext(ThemeContext);
};
// Utilizzo
import { useTheme, ThemeProvider } from './ThemeContext';

function MyComponent() {
  const { theme, toggleTheme } = useTheme();

  return (
    

Questo è un componente con tema.

); } function App() { return ( ); } export default App;

In questo esempio, il ThemeProvider determina dinamicamente il tema in base allo stato isDarkTheme. I componenti che utilizzano l'hook useTheme si ri-renderizzeranno automaticamente al cambio del tema.

Pattern 3: Contesto con useReducer per Stati Complessi

Per gestire logiche di stato complesse, combinare la Context API con useReducer è un approccio eccellente. useReducer fornisce un modo strutturato per aggiornare lo stato in base ad azioni, e la Context API ti permette di condividere questo stato e la funzione di dispatch attraverso la tua applicazione.

Esempio: una Semplice Lista di Cose da Fare

// TodoContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

type TodoAction = 
  | { type: 'ADD_TODO'; text: string } 
  | { type: 'TOGGLE_TODO'; id: number } 
  | { type: 'DELETE_TODO'; id: number };

interface TodoContextType {
  state: TodoState;
  dispatch: React.Dispatch;
}

const initialState: TodoState = {
  todos: [],
};

const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
};

const TodoContext = createContext(undefined);

interface TodoProviderProps {
  children: ReactNode;
}

export const TodoProvider: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  const value: TodoContextType = {
    state,
    dispatch,
  };

  return {children};
};

export const useTodo = () => {
  const context = useContext(TodoContext);
  if (!context) {
    throw new Error('useTodo must be used within a TodoProvider');
  }
  return context;
};
// Utilizzo
import { useTodo, TodoProvider } from './TodoContext';

function TodoList() {
  const { state, dispatch } = useTodo();

  return (
    
    {state.todos.map((todo) => (
  • {todo.text}
  • ))}
); } function AddTodo() { const { dispatch } = useTodo(); const [text, setText] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: 'ADD_TODO', text }); setText(''); }; return (
setText(e.target.value)} />
); } function App() { return ( ); } export default App;

Questo pattern centralizza la logica di gestione dello stato all'interno del reducer, rendendola più facile da comprendere e testare. I componenti possono inviare azioni (dispatch) per aggiornare lo stato senza doverlo gestire direttamente.

Pattern 4: Aggiornamenti Ottimizzati del Contesto con `useMemo` e `useCallback`

Come accennato in precedenza, una considerazione chiave sulle prestazioni con la Context API sono i ri-rendering non necessari. L'uso di useMemo e useCallback può prevenire questi ri-rendering assicurando che vengano aggiornate solo le parti necessarie del valore del contesto e che i riferimenti alle funzioni rimangano stabili.

Esempio: Ottimizzazione di un Contesto di Tema

// OptimizedThemeContext.js
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const defaultTheme: Theme = {
    background: 'white',
    color: 'black'
};

const darkTheme: Theme = {
    background: 'black',
    color: 'white'
};

const ThemeContext = createContext({
    theme: defaultTheme,
    toggleTheme: () => {}
});

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC = ({ children }) => {
  const [isDarkTheme, setIsDarkTheme] = useState(false);
  const theme = isDarkTheme ? darkTheme : defaultTheme;

  const toggleTheme = useCallback(() => {
    setIsDarkTheme(!isDarkTheme);
  }, [isDarkTheme]);

  const value: ThemeContextType = useMemo(() => ({
    theme,
    toggleTheme,
  }), [theme, toggleTheme]);

  return {children};
};

export const useTheme = () => {
  return useContext(ThemeContext);
};

Spiegazione:

Senza useCallback, la funzione toggleTheme verrebbe ricreata ad ogni render del ThemeProvider, causando la modifica del value e innescando ri-rendering in tutti i componenti consumatori, anche se il tema stesso non fosse cambiato. useMemo garantisce che un nuovo value venga creato solo quando le sue dipendenze (theme o toggleTheme) cambiano.

Pattern 5: Selettori di Contesto

I selettori di contesto consentono ai componenti di sottoscrivere solo parti specifiche del valore del contesto. Ciò previene ri-rendering non necessari quando altre parti del contesto cambiano. Librerie come `use-context-selector` o implementazioni personalizzate possono essere utilizzate per ottenere questo risultato.

Esempio con un Selettore di Contesto Personalizzato

// useCustomContextSelector.js
import { useContext, useState, useRef, useEffect } from 'react';

function useCustomContextSelector(
  context: React.Context,
  selector: (value: T) => S
): S {
  const value = useContext(context);
  const [selected, setSelected] = useState(() => selector(value));
  const latestSelector = useRef(selector);
  latestSelector.current = selector;

  useEffect(() => {
    let didUnmount = false;
    let lastSelected = selected;

    const subscription = () => {
      if (didUnmount) {
        return;
      }
      const nextSelected = latestSelector.current(value);
      if (!Object.is(lastSelected, nextSelected)) {
        lastSelected = nextSelected;
        setSelected(nextSelected);
      }
    };

    // Tipicamente qui ci si iscriverebbe ai cambiamenti del contesto. Poiché questo è un esempio semplificato,
    // chiameremo semplicemente la subscription immediatamente per l'inizializzazione.
    subscription();

    return () => {
      didUnmount = true;
      // Annullare l'iscrizione ai cambiamenti del contesto qui, se applicabile.
    };
  }, [value]); // Riesegui l'effetto ogni volta che il valore del contesto cambia

  return selected;
}

export default useCustomContextSelector;
// ThemeContext.js (Semplificato per brevità)
import React, { createContext, useState, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  setTheme: (newTheme: Theme) => void; 
}

const ThemeContext = createContext(undefined);

interface ThemeProviderProps {
  children: ReactNode;
  initialTheme: Theme;
}

export const ThemeProvider: React.FC = ({ children, initialTheme }) => {
  const [theme, setTheme] = useState(initialTheme);

  const value: ThemeContextType = {
    theme,
    setTheme
  };

  return {children};
};

export const useThemeContext = () => {
    const context = React.useContext(ThemeContext);
    if (!context) {
        throw new Error("useThemeContext must be used within a ThemeProvider");
    }
    return context;
};

export default ThemeContext;
// Utilizzo
import useCustomContextSelector from './useCustomContextSelector';
import ThemeContext, { ThemeProvider, useThemeContext } from './ThemeContext';

function BackgroundComponent() {
  const background = useCustomContextSelector(ThemeContext, (context) => context.theme.background);
  return 
Sfondo
; } function ColorComponent() { const color = useCustomContextSelector(ThemeContext, (context) => context.theme.color); return
Colore
; } function App() { const { theme, setTheme } = useThemeContext(); const toggleTheme = () => { setTheme({ background: theme.background === 'white' ? 'black' : 'white', color: theme.color === 'black' ? 'white' : 'black' }); }; return ( ); } export default App;

In questo esempio, BackgroundComponent si ri-renderizza solo quando la proprietà background del tema cambia, e ColorComponent si ri-renderizza solo quando la proprietà color cambia. Questo evita ri-rendering non necessari quando l'intero valore del contesto cambia.

Pattern 6: Separare le Azioni dallo Stato

Per applicazioni più grandi, considera di separare il valore del contesto in due contesti distinti: uno per lo stato e un altro per le azioni (funzioni di dispatch). Questo può migliorare l'organizzazione del codice e la testabilità.

Esempio: Lista di Cose da Fare con Contesti Separati per Stato e Azioni

// TodoStateContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

const initialState: TodoState = {
  todos: [],
};

const TodoStateContext = createContext(initialState);

interface TodoStateProviderProps {
  children: ReactNode;
}

export const TodoStateProvider: React.FC = ({ children }) => {
  const [state] = useReducer(todoReducer, initialState);

  return {children};
};

export const useTodoState = () => {
  return useContext(TodoStateContext);
};

// TodoActionContext.js
import React, { createContext, useContext, Dispatch, ReactNode } from 'react';

type TodoAction = 
  | { type: 'ADD_TODO'; text: string } 
  | { type: 'TOGGLE_TODO'; id: number } 
  | { type: 'DELETE_TODO'; id: number };

const TodoActionContext = createContext | undefined>(undefined);

interface TodoActionProviderProps {
    children: ReactNode;
}

export const TodoActionProvider: React.FC = ({children}) => {
    const [, dispatch] = useReducer(todoReducer, initialState);

    return {children};
};


export const useTodoDispatch = () => {
  const dispatch = useContext(TodoActionContext);
  if (!dispatch) {
    throw new Error('useTodoDispatch must be used within a TodoActionProvider');
  }
  return dispatch;
};

// todoReducer.js
export const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
};
// Utilizzo
import { useTodoState, TodoStateProvider } from './TodoStateContext';
import { useTodoDispatch, TodoActionProvider } from './TodoActionContext';

function TodoList() {
  const state = useTodoState();

  return (
    
    {state.todos.map((todo) => (
  • {todo.text}
  • ))}
); } function TodoActions({ todo }) { const dispatch = useTodoDispatch(); return ( <> ); } function AddTodo() { const dispatch = useTodoDispatch(); const [text, setText] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: 'ADD_TODO', text }); setText(''); }; return (
setText(e.target.value)} />
); } function App() { return ( ); } export default App;

Questa separazione consente ai componenti di sottoscrivere solo il contesto di cui hanno bisogno, riducendo i ri-rendering non necessari. Inoltre, rende più facile testare unitariamente il reducer e ogni componente in isolamento. Inoltre, l'ordine di annidamento dei provider è importante. L'ActionProvider deve avvolgere lo StateProvider.

Best Practice e Considerazioni

Conclusione

La React Context API è uno strumento versatile per la gestione dello stato. Comprendendo e applicando questi pattern avanzati, puoi gestire efficacemente stati complessi, ottimizzare le prestazioni e costruire applicazioni React più manutenibili e scalabili. Ricorda di scegliere il pattern giusto per le tue esigenze specifiche e di considerare attentamente le implicazioni sulle prestazioni del tuo uso del contesto.

Man mano che React evolve, evolveranno anche le best practice che circondano la Context API. Rimanere informati sulle nuove tecniche e librerie ti garantirà di essere attrezzato per affrontare le sfide della gestione dello stato nello sviluppo web moderno. Considera di esplorare pattern emergenti come l'uso del contesto con i segnali (signals) per una reattività ancora più granulare.